Skip to content

threedim: PrimitiveCloud point-cloud / Gaussian-splat family (from #2109 stack) - #2123

Open
jcelerier wants to merge 104 commits into
planf/objectsfrom
planf2/pointcloud
Open

threedim: PrimitiveCloud point-cloud / Gaussian-splat family (from #2109 stack)#2123
jcelerier wants to merge 104 commits into
planf/objectsfrom
planf2/pointcloud

Conversation

@jcelerier

Copy link
Copy Markdown
Member

The PrimitiveCloud point-cloud / Gaussian-splat family — the last piece of the 3D-object work, deferred from #2122 because its build wiring is more involved.

Stacked on #2122; retargets as the stack merges.

What it adds

  • PrimitiveCloud: a PLY parser, a .splat binary reader, an SPZ codec for compressed 3D Gaussian splats (via new spz + zstd submodules), a scene-from-cloud node, and a format override.
  • TagAs helper, and asset-loader routing for .ply / .splat / .spz.

Kept, not retired (per the "keep both" decision)

The upstream 3D branch deletes the old Splat/ render node in favour of this family; here it is preserved — the Splat render node and Splat loader remain registered alongside PrimitiveCloud.

Notes

The earlier deferral cited three blockers; two dissolved once built from the branch tip rather than an intermediate commit (the Gfx::FormatRegistry reference is an empty-stub include at tip, unused), and the libspz -fPIC link failure was from applying the zstd build wiring incompletely — the complete 3rdparty/zstd.cmake + redirect + spz stub build cleanly.

Validation

  • Builds cleanly (spz/zstd vendored, PIC-correct).
  • ctest: 17/17.
  • Process sweep under ASAN + UBSan: the new point-cloud model is constructed and round-tripped — 0 memory errors, 0 undefined-behavior in score's own code, 0 crashes.

🤖 Generated with Claude Code

@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from 2b22ce2 to 3f31f9d Compare July 17, 2026 03:29
@jcelerier
jcelerier force-pushed the planf/objects branch 2 times, most recently from eb14ad5 to 899f99b Compare July 17, 2026 11:58
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from 3f31f9d to ebeb8f4 Compare July 17, 2026 11:58
jcelerier added a commit that referenced this pull request Jul 17, 2026
… flush

test_unit_threedim_loaders: AssetLoader/GltfParser/FbxParser/TinyObj/Ply/Vcg
+ the PrimitiveCloud parsers, compiled straight into the test (plugin is
-fvisibility=hidden). test_unit_primitivecloud: PLY/.splat/.spz parsers +
scene helpers. ObjectGallery: explicit llvm profile flush on _Exit +
SEGV/ABRT so coverage runs of the render-regression harness still emit
.profraw (atop this branch's render-regression mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from ebeb8f4 to fc9ac65 Compare July 17, 2026 14:10
jcelerier added a commit that referenced this pull request Jul 17, 2026
… flush

test_unit_threedim_loaders: AssetLoader/GltfParser/FbxParser/TinyObj/Ply/Vcg
+ the PrimitiveCloud parsers, compiled straight into the test (plugin is
-fvisibility=hidden). test_unit_primitivecloud: PLY/.splat/.spz parsers +
scene helpers. ObjectGallery: explicit llvm profile flush on _Exit +
SEGV/ABRT so coverage runs of the render-regression harness still emit
.profraw (atop this branch's render-regression mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from fc9ac65 to ec5deba Compare July 17, 2026 14:29
jcelerier added a commit that referenced this pull request Jul 17, 2026
… flush

test_unit_threedim_loaders: AssetLoader/GltfParser/FbxParser/TinyObj/Ply/Vcg
+ the PrimitiveCloud parsers, compiled straight into the test (plugin is
-fvisibility=hidden). test_unit_primitivecloud: PLY/.splat/.spz parsers +
scene helpers. ObjectGallery: explicit llvm profile flush on _Exit +
SEGV/ABRT so coverage runs of the render-regression harness still emit
.profraw (atop this branch's render-regression mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch 2 times, most recently from 7855239 to ef58365 Compare July 18, 2026 13:37
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from ef58365 to da52f0e Compare July 18, 2026 14:17
@jcelerier
jcelerier force-pushed the planf/objects branch 2 times, most recently from 3d06f50 to 3fe10b1 Compare July 18, 2026 14:23
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from da52f0e to c067bbe Compare July 18, 2026 14:23
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch 2 times, most recently from 337c225 to 5c1950c Compare July 19, 2026 03:14
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from 5c1950c to f22647e Compare July 19, 2026 21:59
@jcelerier
jcelerier force-pushed the planf/objects branch 2 times, most recently from df39bc1 to c59c898 Compare July 20, 2026 23:09
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from f22647e to c19744e Compare July 20, 2026 23:09
@jcelerier
jcelerier force-pushed the planf2/pointcloud branch from c19744e to 2e67992 Compare July 21, 2026 04:55
jcelerier and others added 30 commits August 19, 2026 23:19
The scene testers name Primitive cube, Camera and DirectionalLight in their
`Wire:` clauses. Those are halp processes in score_plugin_threedim, and
oscr::GfxNode<T> wraps one into a score::gfx node given only a
score::DocumentContext -- no interval, no executor, no createProcess command.
score::gfx::ScenePreprocessorNode (the SceneFlattener) was always constructible;
what it lacked was an ossia::scene_spec source, which a Crousti-wrapped producer
supplies.

Lands in this branch rather than with the other sweeps because it compiles
Camera.cpp and Light.cpp straight into the target -- the plugin is built with
hidden visibility -- and both arrive here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the point-cloud objects: a PLY parser, a .splat binary reader, an SPZ
codec (compressed 3D Gaussian splats, via the new spz + zstd submodules),
a scene-from-cloud node and a format override, plus the TagAs helper. The
asset loader routes .ply/.splat/.spz through them.

The existing Splat render node and Splat loader are kept alongside the new
family rather than retired, so both remain available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
ObjectGallery --render <name>   : add one object, route its texture output to
                                  an offscreen window, play, and exit 0 if it
                                  rendered a full run without crashing (a
                                  render-path crash kills the process, which
                                  the driver attributes to that object).
render-sweep.sh [filter]        : run --render for each object on BOTH GL
                                  backends (llvmpipe and the machine's GPU via
                                  X11) and tally crashes.

Texture objects (ISF, images, model display, ...) render clean on both
backends. Two follow-ups: geometry-only objects need a display chain (they
currently abort when run standalone), and the offscreen readback does not yet
composite the routed texture so the non-blank check is informational.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
ObjectGallery gains a --shader <file> mode: it loads a shader tester
(.fs/.frag/.glsl as an ISF Shader, .cs/.comp/.csf as a Compute Shader) into a
process, routes its texture output to an offscreen window and renders it,
exiting non-zero if it fails to compile or crashes.

csf-sweep.sh drives that over the csf-examples corpus on BOTH GL backends
(llvmpipe and the machine's GPU via X11). On the csf-testers set, 100/121
shaders render on both backends; the remaining 21 are geometry-producing
compute shaders that need a downstream raster stage to display (see the
corpus's tests.txt) — chaining them is the next step.

Also fixes the sweep drivers to pass per-backend env via `env`, since bash
does not treat VAR=val from an expanded "$@" as assignments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
test_unit_threedim_loaders: AssetLoader/GltfParser/FbxParser/TinyObj/Ply/Vcg
+ the PrimitiveCloud parsers, compiled straight into the test (plugin is
-fvisibility=hidden). test_unit_primitivecloud: PLY/.splat/.spz parsers +
scene helpers. ObjectGallery: explicit llvm profile flush on _Exit +
SEGV/ABRT so coverage runs of the render-regression harness still emit
.profraw (atop this branch's render-regression mode).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Every allocation in the createRenderTarget family was a SCORE_ASSERT, so a
backend declining a configuration -- an MSAA depth attachment it does not
implement, a format it will not render to -- killed the process. That is the
wrong failure for a runtime condition: a headless sweep dies on the first
unsupported case instead of reporting it, and an interactive session loses the
document.

37 asserts across the nine render-target functions now return an empty
TextureRenderTarget, which operator bool() already reports as invalid, after
releasing what that function allocated. `texture` is left alone in the overload
that is handed one -- it belongs to the caller. SCORE_ASSERT(tex) stays: a null
argument there is a broken caller, not a driver refusal.

Returning an invalid target only moves the crash unless the callers cope, so
the offscreen path is closed off too: buildPipeline returns an empty pipeline
rather than asserting on the missing render pass, InvertYRenderer::init gives up
when it has no target, and finishFrame skips the pass instead of handing a null
target to beginPass.

Measured on the case that used to abort (asset loader -> ModelDisplay ->
Window, Qt 6.4): exit 133 with a core dump before, exit 0 now, with the reason
in the log at each step. The 130-case corpus is unchanged: RENDER 89, BLANK 41,
NORENDER 0, CRASH 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
7f007b8 covers the PLY / .splat / .spz parsers thoroughly, but none of
the AssetLoader dispatch above them: the existing .ply section writes a
mesh PLY, so only the FALSE branch of ply_is_splat_shaped() was incidentally
exercised, and .splat, .spz, rebuild_format_state() and the format_override
input had no test at all.

Five cases added to the existing AssetLoader section, all inputs generated
in-test the way the file already does (splat-shaped ASCII PLY built column
by column, hand-packed 32-byte .splat rows, .spz via spz::saveSpz):

  * a splat-shaped .ply routes to the cloud path (cloud payload, no mesh,
    3dgs.classic format, basename label) while xyz + face stays on the
    miniply mesh path -- both sides of the fork, explicitly;
  * .splat and .spz dispatch to parse_splat_binary / parse_spz and produce
    a one-node scene labelled with the basename, with a non-multiple-of-32
    .splat and an undecodable .spz rejected cleanly;
  * suffix matching is case-insensitive (.SPLAT, .PLY) through hasSuffixCI;
  * the three-stage m_parsed -> m_overridden -> m_wrapped contract: setting
    format_override rewrites the cloud format, bumps the version, shares the
    raw_data payload, rebuilds the wrap, and leaves m_parsed_state intact so
    clearing the override restores the autodetected format exactly;
  * a mesh-path asset with an override set still clones the state but keeps
    its tree identity, since there is no cloud to rewrite.

Seen RED four ways, reverted after each: forcing ply_is_splat_shaped to
false (1 assert); disabling the .splat and .spz branches (4); making
rebuild_format_state overwrite m_parsed_state (4); making the suffix match
case-sensitive (2).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
(cherry picked from commit 7e4f5d7053a5b009787064cc15b1c213956cd547)
…with CTest

Three suites were built but never run.

CaptureAdjustShaderTest (score-plugin-gfx/CMakeLists.txt) has been an
add_executable with no add_test since it landed, unlike EncoderTester and
ReadbackTester twenty lines below it. It skips cleanly when no RHI is
available, so it gets the same "gui" label and environment as its
neighbours. It passes on this machine: OpenGL/GLSL 330, 28 checks,
0 failures, worst channel delta 1/255.

be7dc83 / 46322c3 / 72a02e4 build a dual-backend render-regression
sweep (render-sweep.sh, csf-sweep.sh) that no CMake file referenced. Both
are now opt-in CTest entries labelled "gui", RUN_SERIAL, skipping with 77
when the gallery binary, an X display or the shader corpus is missing, and
taking the built binary's path through OBJECT_GALLERY.

Registering them turned up two reasons the quoted corpus verdicts could not
have come from this code:

  * render-sweep.sh passed its per-backend environment as bare VAR=VALUE
    words expanded from "$@". Bash decides assignment-prefix status at parse
    time, so LIBGL_ALWAYS_SOFTWARE=1 became the command name and the entire
    llvmpipe column was a fabricated exit 127 -- 256 of 256 objects. 72a02e4
    made exactly this fix to csf-sweep.sh and missed its sibling. Fixed with
    the same `env` prefix.

  * ObjectGallery's "no texture output" path returned through qApp->exit(3),
    and the MinimalGUIApplication teardown aborts in
    ApplicationContext::interfaces<Process::MagnetismAdjuster>() while
    destroying the ScenarioDocumentPresenter. Every geometry-only object was
    therefore counted as SIGABRT rather than as a skip. It now takes the same
    hard _Exit the success path already takes, with the abort recorded in a
    comment; the underlying teardown defect is untouched.

With both fixed the real figure over the 256 registered objects is
ok=25 skip=228 crashes=3 on each backend (CLAP: SIGTRAP; LV2 and VST:
40s timeout in the plugin scanner), so the sweep exits 1 as shipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
(cherry picked from commit e0004da3406f49fefcba0cb115e82a676e15ea59)
Same defect as the --render "no texture output" path fixed alongside the
sweep registration: qApp->exit(N) runs the MinimalGUIApplication teardown,
which aborts in ApplicationContext::interfaces<Process::MagnetismAdjuster>()
while destroying the ScenarioDocumentPresenter, so the process dies with
SIGABRT before it can return N.

For --shader that turned all 21 geometry-producing compute shaders in the
csf-testers corpus -- the ones 72a02e4's message documents as needing a
downstream raster stage -- into FAIL(134) instead of skip(3), and csf-sweep.sh
exited 1 on a corpus with nothing actually wrong with it.

Exit codes 5 (unsupported extension), 1 (no such process) and 3 (no texture
output) now take the same std::_Exit + coverage flush the success path
already took. Also moves the <cstdlib> include out of the file header comment
into the include block.

The teardown abort itself is untouched: it is an application-level defect that
would fire for any MinimalGUIApplication tool closing a scenario document
normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
(cherry picked from commit ef73371da1e45a393212890c08625583dcb34b77)
…o expected-RED guards

Four bodies of threedim test work that all register into tests/threedim/CMakeLists.txt
and depend on each other's sources, kept as one commit so no intermediate state
registers a target whose source arrives later in the branch.

 - the pure scene_state -> scene_state algebra (SceneSwitch, CameraSwitch,
   SceneGroup, SceneSelector, SceneDuplicator, TagAs), which had zero references
   anywhere under tests/ (52104d2, f92f98c, f0a202a)
 - CameraArray's six cubemap faces, the producer side of #163
 - Transform3D composition
 - source-text guards for the four fisheye projections and the combo indices that
   select them (7b2704d, 0a314b5)
 - ShadowCascadeSetup's cascade math: split scheme, findActiveCamera id filtering,
   the reverse-Z NDC flip and the zPad direction (f92f98c)
 - two EXPECTED-RED guards, isolated in their own target so an open defect does not
   mask the green suites and the entry flips to PASS the day it is fixed:
     * #163, the `camera` auxiliary published with a one-entry byte_size
     * CameraSwitch's Blend accumulating onto default-constructed components, so
       blending a single camera at weight 1.0 -- the identity -- returns
       yfov + pi/4, zfar + 1000, scale 2

Every guard was seen red before it was trusted; the controls are transcribed in
~/ossia/gapmap/written-wtest2.md and written-wtest3.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gfx/Window was the largest wholly-uncovered block of score-plugin-gfx:
OutputMapping.cpp (852 instrumented lines) and DesktopLayout.cpp (401) were
both at 0.0% with no reference to either name anywhere under tests/.

Neither needs a GPU. The serializers are plain data; the canvases are
QGraphicsView subclasses whose logic is coordinate mapping, snapping,
clamping, lock modes, re-indexing and warp, all reachable by synthesising
QGraphicsSceneMouseEvent / QGraphicsSceneHoverEvent and handing them to
QGraphicsScene::sendEvent — the same path the scene itself uses — plus
QMouseEvent to the view's viewport for the warp handle drag.

test_unit_output_mapping_serialization pins the JSON and DataStream forms of
Gfx::OutputMapping: both sides of every omit-if-default branch, the
CornerWarp identity predicate corner by corner, the pre-LockMode
Locked/LockSizeToInput compatibility path, and the wrong-arity array guards.

test_gfx_window_layout pins the two canvases: updateAspectRatio and the UV
invariance of the rescale it performs, setMappings/getMappings round-trip,
add/remove/re-index, snap on/off/zero-distance/out-of-threshold, clamping to
the canvas rather than to the warp-margin scene rect, edge resize with its
minimum-size and scene bounds, the four blend handles with their 0..0.5 clamp
and the ctrl precision factor, all four lock modes, the hover cursor table,
warp enter/exit/toggle/reset and the four warp handles, desktop<->scene
coordinate inverses, detectScreen, and updateItem's deliberate detachment of
the geometry callback.

Both files compile the plugin translation units through
score_plugin_hidden_sources().

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Gfx/Graph/decoders/ColorSpace.hpp (1387 lines) and Tonemap.hpp (~600) are
header-only families of pure functions of a Video::ImageFormat, and they
decide the GLSL that every YUV sampling shader in the plugin is built from.
Both were entirely uncovered: their only consumers are the decoders/*.hpp
samplers, which need a GPU and a decoded frame, so the decision tree was
never reached even though the decision itself needs neither.

test_unit_colorspace_shader sweeps colorMatrix() over every AVColorSpace and
every range, and pins: the RGB passthrough; each matrix-coefficient set
against the SCORE_GFX_CONVERT_* macro it must return (limited, full, and the
fact that only AVCOL_RANGE_JPEG counts as full); BT.709 coefficients over P3
primaries rerouting to the P3 pipeline; BT.2020 CL being approximated by NCL;
the three BT.2100 encodings (NCL / SMPTE 2085 / ICtCp) being distinct
programs; ICtCp choosing its LMS inverse from the transfer function; the four
output formats being four distinct programs; the SDR path carrying the
resolved tonemapper; HLG in SDR running the OOTF and branching on the
tonemapper's gamut class; passthrough applying no EOTF; the whole H.273
chroma-derived (12 and 13) delegation to primaries; and the unspecified
fallback down to the 1280-pixel width heuristic.

Also pins the numeric helpers the HDR pipelines are built on: MaxCLL and the
mastering-display luminance with their plausibility windows and the
zero-denominator guard, the PQ 10000/peak normalisation factor, and the
BT.2408 203-nit reference white.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
GpuCapabilities.hpp holds the rung-selection logic every vendor video
strategy (AJA, DeckLink, Magewell, Rivermax) consults before deciding how
frames reach the GPU. The probe needs a driver; the decisions taken on its
result are constexpr predicates over a plain struct and are checkable on any
host. GpuCapabilities.cpp was at 0.0% coverage.

The distinctions pinned are the ones that degrade silently rather than fail:
CUDA VMM and nvidia_peermem are each insufficient alone for tier 0; DVP entry
points without the library (and the library without entry points) are not
tier 1; AMD bus-addressable memory is tier 0 and NOT tier 2 even though it
makes amd.any() true; and rulesOutNvidiaPaths() must return false for
GpuVendor::Unknown, since flipping that would disable DVP on every host whose
vendor probe comes back empty -- Mesa's GL driver among them.

Also covers gpuVendorName / qrhiBackendName over every enumerator plus an
out-of-range value, and asserts of probeContextFree() that it is idempotent,
NUL-terminates both fixed-size name buffers, reports the OS it was built for,
and leaves the GL-only fields (amd.*, backend) untouched -- claiming those
without a context would send a strategy down an unverified rung.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Gfx/Window/TestCard.cpp (renderTestCard) is the fallback pattern the
multi-window output device shows when no source is routed, and was at 0.0%
coverage. It is a pure QImage producer: no GPU, no window.

Pins the degenerate-size guard on both axes, the returned geometry and
RGB32 format, determinism across two calls, that every layer actually
painted (the grey ramps alone put the distinct-colour count well past a bare
fill, and the rainbow strip row carries at least eight), and that the qMax
floors every layer sizes itself with survive 1x1, 3x2 and extreme aspect
ratios.

Added to test_gfx_window_layout rather than a new target: same subsystem,
same APP fixture, same hidden-sources list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
createGPUVideoDecoder() is SCORE_PLUGIN_GFX_EXPORT and takes only a
Video::ImageFormat -- no QRhi, no decoder, no frame. The whole pixel-format
to decoder-class switch in GPUVideoDecoderFactory.cpp (0.0% covered) is
therefore checkable on any host; only init(), which builds the shaders and
textures, needs a render list, and the L3 GPU suites cover that end.

Where the target is one of the two generic decoders the class proves nothing
(a dozen formats share PackedDecoder), so those rows assert the QRhi texture
format, the bytes-per-pixel and the swizzle filter the factory chose -- which
is the entire content of the decision. Includes the 64x/16x left-alignment
scaling for 10- and 12-bit planar RGB, which is a brightness bug rather than
a crash when swapped; that the caller's filter is appended and not replaced;
that the byte-swapped twins (UYVY/YUYV, RGB/BGR, ARGB/ABGR) do not collapse
onto one another; the fourcc default branch that reinterprets the enum's four
bytes as a HAP/DXV codec tag, including an unknown tag yielding nullptr; and
that an unsupported format returns nullptr rather than an EmptyDecoder that
would silently render nothing.

Also pins PixelFormatInfo::fromAVPixelFormat and ::fromCodecParameters:
subsampling, depth, plane count, alpha, the sw_format-over-codecpar
preference, and that bits_per_raw_sample only ever raises the depth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
…owNode

Everything under tests/gfx/ renders into an offscreen BackgroundNode and reads
it back, so score::gfx::Window (0/255 lines), ScreenNode and MultiWindowNode —
every path that exists only because a real platform surface with a swap chain
sits in front of the renderer — were unreachable. A prior session concluded
they were unreachable "by construction"; they are not, they just need a display.

The new rig terminates the same score::gfx::Graph on a ScreenNode or a
MultiWindowNode instead of a BackgroundNode, pumps the Qt event loop until the
platform exposes the surface, and drives the manual-render path the app's
non-vsync timer drives. Registered GUI, so the targets are labelled "gui" and a
displayless CI excludes them; every case SKIPs cleanly with no windowing system
and per RHI backend.

Covered: expose / swap-chain creation, resize -> createOrResize -> renderSize
mirroring, the render-size override and its degenerate-size reset, hide and
re-show (the m_notExposed latch), releaseSwapChain/resizeSwapChain, the
device-lost latch and its once-only deferred callback, the key/mouse/interactive
event fan-out including auto-repeat filtering, the DeferredDelete swallow, the
swapchain flag/format and graphics-API teardown-and-rebuild, start/stopRendering,
the vsync-callback kick on the null->non-null transition, and cursor/title/
position/screen setters. For MultiWindowNode: one swap chain per mapping, the
mapping seeding, every live parameter setter with its out-of-range guards, the
offscreen-target recreation on a render-size change, per-window close and
re-expose while the siblings keep running, the renderBlack fallback, and the
empty-mapping no-op.

The teardown cases run on a Graph-less rig on purpose: see the FINDING in
ScreenOutputFindings.cpp.
The .sh integration harnesses need flock, oscsend and POSIX tooling. They were
registered unconditionally, so on Windows ctest cannot execute them at all and
reports BAD_COMMAND — which counts as a failure rather than a skip, and made
5 of the 31 reported Windows failures pure noise.

Gate the registrations on SCORE_HAS_SHELL_HARNESS. On Linux and macOS nothing
changes: all six are still registered and still skip via SKIP_RETURN_CODE 77
when their prerequisites are missing.
Gfx/Window/OutputPreview.cpp (403 lines, 0%) is pure QPainter work on a plain
QWidget, so QWidget::grab() renders it through paintEvent offscreen and the
result is checkable pixel by pixel — no GPU, no display.

Covered: the four preview contents (black, per-output test card, global test
card with and without a card supplied, output identification and its colour
table), the source-rect window into the global card, the soft-edge blend
overlay on each of the four sides, the corner warp and the rotation/mirror
transform (including the "no transform requested" fast path), the fullscreen
double-click toggle and the index it reports, and OutputPreviewWindows'
window-per-mapping bookkeeping: growth, shrink, geometry sync on and off,
fullscreen mappings, out-of-range screen indices, content propagation and the
shared test-card rebuild on an input-resolution change.

The blend assertions are analytic rather than "it changed": the overlay mirrors
the shader's content * pow(t, gamma), so a flat identification colour makes the
expected value computable, and the gamma is pinned by comparing gamma 1 against
gamma 3 at the same t.
Gfx/Window/WindowSettingsWidget.cpp (1161 lines, 0%) is the only place a
Gfx::WindowSettings is built from, and pushed back into, the UI. It has no
accessor for any of its controls, so the test addresses them the way a user
does — by the label of the QFormLayout row they sit in — which keeps the
assertions readable and makes a renamed row fail loudly instead of drifting
silently onto a neighbouring spin box.

Covered: the defaults, the full settings round-trip (mode, swapchain flag and
format, input resolution, three mappings), mode switching and the preview
windows it creates and destroys, the flag/format combos, the per-output
inspector (selection, window position/size/fullscreen, the source-rect spin
boxes rewriting the mapping quad, all eight blend fields, rotation and
mirroring, the four lock modes and the enable/derive rules they impose on the
size fields, and the screen combo's Default offset), the input resolution, and
the add/remove-output path with its selection semantics.

Runs offscreen under run_in_app; no GPU and no display.
Gfx/Libav/LibavSettingsWidget.cpp (781 lines, 0%) and Gfx/Libav/LibavPresets.cpp
(301, 0%). Most of the widget is libav introspection rather than plumbing: the
muxer choice filters the encoder lists to what that muxer accepts, an encoder
choice repopulates the pixel/sample format lists from that codec's declared
configuration, and the validation label comes from av_guess_format /
avcodec_find_encoder_by_name.

Covered: the direction switch and its stacked pages, the key=value option text
parser in both directions (trimming, blank lines, a line with no '=' and a line
with two), every output field including all five input-transfer entries and
their AVColorTransferCharacteristic values, the muxer -> encoder narrowing (and
that clearing the muxer does NOT restore the full lists — onMuxerChanged returns
early on an empty name), the encoder -> format repopulation with the previous
selection preserved across a compatible switch and dropped when the encoder is
cleared, the validation label, the ffmpeg options dialog, the settings
round-trip and toOutputSettings, and every preset the enumerators publish.

The libav build decides which muxers and codecs exist, so each case SKIPs rather
than fails when the one it needs is absent.
Gfx/Window/WindowDevice.hpp (406 branches, 0%) and MultiWindowDevice.hpp (420,
0%) are where the score::gfx sinks become remote-controllable, and neither is
reachable without building the device through the real protocol factory on a
real document. The test does exactly that — WindowDevice{settings, doc.context()}
+ reconnect() — and then drives the published addresses.

Single window: the whole address surface is enumerated (screen, position, size,
rendersize, fullscreen, fps, cursor scaled/gl/absolute/visible, the eight tablet
axes, key press/release code and text), /size and /position are pushed and read
back off the QWindow, a window move is checked to travel back into /position
through the re-entrancy lock, /cursor/visible is toggled both ways, and a key
press and release are sent to the window and read out of the parameters.

Multi window: the per-window subtrees are enumerated and checked to stop at the
mapping count, /i/source/position and /i/source/size are shown to address the
two halves of one QRectF, /i/blend/<side>/{width,gamma} to be independent (each
carries the other over rather than resetting it), /rendersize to resize the
shared offscreen target and /i/size to resize that window only.

Also adds a second FINDING to the isolated target: a window resize discards the
render-size override, because RenderList::resizeSwapchainSizedTargets writes the
swapchain size straight into RenderState::renderSize without consulting the
output. Setting it works; the first resize afterwards throws it away.
stringIsScript scans for the punctuation inline source always carries, but the
scan was dead code: the function ended in an unconditional `return true`, so
anything that is not an existing file was treated as a program. A missing path
therefore never reached the "cannot open" branch — /definitely/does/not/exist.js
parses as a regex literal with invalid flags and surfaced as a SyntaxError at
exit 3, rather than the exit 2 the failure-reporting commit describes.

Return false when the scan finds nothing, so the input falls through to the file
branch and is reported as unopenable.

The existing test pinned the old behaviour and said so ("Tighten this to == 2 if
that is ever fixed"); it now asserts exit 2 and the diagnostic. A second section
pins the case this could plausibly have broken: inline source with no path-like
shape still runs.

This is deliberately a commit at the tip rather than a fixup into the commit that
introduced the defect, which sits at stack index 3. It is a one-line behavioural
change that cannot affect whether any commit builds, and the per-commit walks in
flight measure buildability — a fixup there would have invalidated every verified
row for no gain that those walks can observe.
Vulkan has validation layers, Metal has MTL_DEBUG_LAYER, and D3D has the debug
layer (reachable in Release since the SCORE_GPU_VALIDATION change). OpenGL had
nothing, which means every "no errors on GL" result so far says only that
nothing was listening.

Install a QOpenGLDebugLogger on the context when SCORE_GPU_VALIDATION is set,
requesting QSurfaceFormat::DebugContext so KHR_debug is actually available.
Logging is synchronous, so a message is emitted at the offending call and a
backtrace points at the real site rather than somewhere later in the frame.

Driver notification chatter (buffer placement hints and the like) stays at
debug level so a genuine warning is not buried in it; SCORE_GPU_VALIDATION=2
promotes everything.

First run already reports a real one, in three separate tests:

  glClear() called with GL_STENCIL_BUFFER_BIT, but there is no stencil buffer.
  Operation will have no effect.

which comes from Qt pairing the depth and stencil clear bits unconditionally
(qrhigles2.cpp) against the depth-only D32F attachments created in Utils.cpp.
Harmless, but it is exactly the class of thing that was invisible before.
The pipewire / gstreamer / libav tests were all probe-gated: absent device ->
ctest SKIP. That is right for a CI container, but on a host that HAS the media
stack it means "the device was absent" quietly explains away an untested code
path, and nobody notices the coverage was never there.

Add a provisioning wrapper and a companion registration helper for tests that
are allowed to assume a capable host:

  with-virtual-media.sh [--video] [--media] -- <harness> [args...]

--video publishes a PipeWire Video/Source from videotestsrc through
pipewiresink, which needs no root and no v4l2loopback, and waits for the node
to actually appear before running anything. --media generates H.264 and raw
clips with ffmpeg. Both are torn down on exit, including on failure.

score_add_media_test() registers these WITHOUT SKIP_RETURN_CODE, so a missing
dependency fails the run. Hosts that genuinely cannot provide the stack set
SCORE_MEDIA_TESTS_OPTIONAL=1 and get the old skip back.

Registered Media_PipewireRoundtrip_input as the first of these; it passes
against the virtual source in ~30s.
--media generated two clips and had no consumer. It now generates one
rawvideo-in-NUT clip per pixel format the decoders claim to handle -- the
only container that carries an arbitrary pix_fmt through untouched -- at
even and at ODD sizes, plus the three heights that trigger each branch of
VideoDecoder's colour-space inference, an audio+video mp4, and three
malformed inputs.

The explicit scale filter is load-bearing: a lavfi source silently rounds
an odd requested size DOWN to even, so testsrc2=size=65x33 yields a 64x32
clip and the odd-dimension axis quietly disappears. The geometry is read
back with ffprobe and a mismatch is fatal, so a clip cannot claim a size
it does not have.

score_add_test() grows NO_CTEST for a harness whose ctest entry has to go
through score_add_media_test(): without it the harness would also be
registered as a bare entry that runs with none of the media provisioned.
…ulary

Video/VideoDecoder.cpp was at 6% of 482 lines, GpuFormats.hpp at 0% of
126, FrameQueue.cpp at 13%, Rescale.cpp at 7%, Thumbnailer.cpp at 12%.
Nothing in the suite opened a video file at all.

LibavDecodeTest runs VideoDecoder over the whole clip matrix the media
runner provisions: metadata against an independent avformat probe of the
same file, one frame per pixel format with every declared plane checked
for a real pointer and a wide-enough stride, odd dimensions, the swscale
path (which is reached only by the formats formatNeedsDecoding() claims,
and whose output must be varied and opaque rather than a zeroed buffer),
the height-driven colour-space inference, video-stream selection out of a
multi-stream file, seek forward / back to zero / past the end, reload onto
another file, malformed inputs, the DecoderConfiguration knobs and the raw
packet route. Registered through the strict provisioning wrapper, so an
absent ffmpeg is a failure and not a silently missing suite.

VideoFrameQueueTest pins the ownership boundary between the decode and
render threads: pool recycling, latest-frame-wins vs single-step dequeue,
and the seek discard marker -- including the guard that must NOT return a
marker frame the queue no longer holds, which would double-own the pixels
a live GPU upload is reading.

GpuFormatsTest sweeps the hardware-decoding vocabulary, and crosses it
with score::gfx::createGPUVideoDecoder(): "no rescale needed" and "here is
your GPU decoder" are two independently maintained switches over
AVPixelFormat, each self-consistent, and a format only one of them knows
renders nothing at all.

Two [!shouldfail] cases record defects rather than assert them:
VideoThumbnailer::process() dereferences a null format context on a file
it could not open (run in a forked child so the crash cannot take the
binary down), and the big-endian grayscale floats skip swscale with no
decoder waiting for them.
GStreamerDevice.cpp was at 4% of 1010 lines and GStreamerLoader.hpp at 0%.
The device runs its own pipeline, so videotestsrc is producer and
reference at once and no external process is needed: gst_parse_launch ->
appsink classification -> FrameQueue -> AVFrame is assertable on the CPU
by scaling one decoded frame down to a single RGBA pixel, with no GPU and
no display. Solid patterns through packed RGB, planar and semi-planar YUV
and packed 4:2:2, plus the pipeline-error paths and the preset
enumerators.

This harness existed and was shelved as "hangs over 12 configurations".
It was never GStreamer: PM::PluginSettingsModel::firstTimeLibraryDownload()
opens a modal question box one second after boot, the harness pumps the
event loop while waiting for frames, and QDialog::exec() never returns
under the offscreen QPA. The ctest entry sets the existing escape hatch.
The second half of the fix is start_execution(): reconnect() only brings
the pipeline to PAUSED, so without it the appsink never yields a sample
and every case reads back nothing. The suite now runs in 19 seconds.

Two [!shouldfail] cases record what the sweep found: no 10-bit planar
format produces a single frame (initFrameFromRawData() refuses every
high-bit-depth planar layout, though both the caps table and the GPU
decoders know them), and a caps format the table does not know is
reinterpreted as RGBA instead of refused -- BGRx turns blue into red,
Y42B turns red into grey.
Video/LibavStreamInput.cpp was at 0% of 338 lines. It is a different
object from VideoDecoder with its own demux thread, its own audio path
and its own pacing decision, and a local file is a legitimate URL for it,
so none of this needs a network or a capture device: probe metadata
against an independent avformat read of the same file, frame delivery,
start/stop/start, score's own "loop" option (the clip is 8 frames long,
so more than 8 frames out is only possible if it wrapped), a lavfi
generator, and the audio stream reaching AudioRingBuffer. The ring buffer
itself is swept over all three writers plus the empty-read case.

A [!shouldfail] case records the defect the sweep turned up: with no
options -- the default, and what a file path typed into the FFmpeg input
device produces -- probe() applies AVFMT_FLAG_NOBUFFER, fflags=nobuffer
and flags=low_delay to every source, and a local file then delivers ZERO
frames. probe() and start() both succeed; nothing comes out. Passing any
option at all, even one as inert as probesize, takes probe() down its
other branch and the same file plays.
…ly have

createRenderTarget's MRT overload allocates one multisample colour
renderbuffer per attachment and resolves each into the caller's
single-sample texture, but it kept those renderbuffers in a local: the
TextureRenderTarget it returned only ever recorded the resolve textures.
TextureRenderTarget::sampleCount() therefore answered 1 -- the resolve
target's count -- for a render target whose attachments are N-sample, and
every pipeline built against it (buildPipeline, buildPipelineWithState,
RenderedRawRasterPipelineNode::initMRTPass) was created with
setSampleCount(1).

D3D12 calls that out per draw:

    ID3D12CommandList::DrawInstanced: The render target sample desc in
    slot 0 does not match that specified by the current pipeline state.
    (pipeline state = count 1 quality 0, render target view = count 2
    quality 0, ID3D12Resource* = 'createRenderTarget::MRT::colorRB')

394 such messages across the gfx suite under the D3D12 debug layer, all
of them naming that renderbuffer, from the three MRT tests (test_gfx_isf
192, test_gfx_isf_mrt 162, test_gfx_isf_mrt_persistent 40). A pipeline
state whose SampleDesc disagrees with the bound RTV is undefined
behaviour, not a hint.

The renderbuffers were also leaked: release() and the failure path both
walk the render target's members, and neither could see them.

Record them on the render target -- the first as colorRenderBuffer, which
is what sampleCount() consults first, the rest alongside the additional
colour textures -- so the pipelines agree with the attachments and the
buffers are released with the rest of the target.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Every render target built by createRenderTarget(format, size, ...) was
allocated MipMapped | UsedWithGenerateMips, and every 2D CSF storage
image likewise, whether or not anything would ever fill the chain. The
call sites that do generate mips (TextureLoader, the GENERATE_MIPS output
path in RenderedRawRasterPipelineNode, and the CSF path guarded by
si.generate_mips) allocate their own textures and pass the flags
themselves; the only other producer is RenderedCSFNode::update(), which
regenerates the mips of its input samplers -- and skips any texture that
is not MipMapped.

So for every render target whose consumer does not filter across mip
levels, levels 1..N were never written. The shader resource view a
sampler binds covers the whole chain regardless, and D3D12 reports it:

    ID3D12CommandQueue1::ExecuteCommandLists: ... resources with
    D3D12_HEAP_FLAG_CREATE_NOT_ZEROED flag with either render target or
    depth stencil flags must be initialized with a Discard/Clear/Copy
    operations before other operations are supported. Resource
    ('createRenderTarget::texture'), Subresource (3) is not initialized
    but is used in SetGraphicsRootDescriptorTable

203 of those across the gfx suite name createRenderTarget::texture,
covering subresources 1 through 6 and never 0 -- exactly the mips, never
the level the render pass writes -- and 30 more name a CSF storage image.

Make the chain follow the demand for it. RenderList allocates it when the
port's resolved sampler spec has a mipmap mode, which is the case that
CSF's regeneration serves; the CSF storage images follow the
GENERATE_MIPS declaration that already gates their generateMips() call.
Everywhere else the texture is single-level, and a sampler with a mipmap
filter over it reads level 0 -- the only level that ever held data.

Both also gain UsedAsTransferSource, which they always needed and were
getting by accident: Qt's Vulkan backend derives
VK_IMAGE_USAGE_TRANSFER_SRC_BIT from UsedAsTransferSource *or*
UsedWithGenerateMips (qrhivulkan.cpp), so dropping the latter took the
readback path's usage bit with it -- 1222 validation lines on the Vulkan
gfx suite, VUID-vkCmdCopyImageToBuffer-srcImage-00186 and the layout
transitions that follow it, against 26 before. Declaring the flag score
actually relies on puts that back at 26. No D3D or GL backend reads the
flag at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rZgzE8JjWvHDtaVUhxpLE
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants